/*
 * Unidata Platform Community Edition
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 * This file is part of the Unidata Platform Community Edition software.
 *
 * Unidata Platform Community Edition is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Unidata Platform Community Edition is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

package org.unidata.mdm.data.service.impl;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.BiPredicate;
import java.util.function.Function;
import java.util.stream.Collectors;

import javax.annotation.Nonnull;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.unidata.mdm.core.service.MetaModelService;
import org.unidata.mdm.core.type.calculables.CalculableHolder;
import org.unidata.mdm.core.type.change.ChangeSet;
import org.unidata.mdm.core.type.data.DataShift;
import org.unidata.mdm.core.type.data.OperationType;
import org.unidata.mdm.core.type.data.RecordStatus;
import org.unidata.mdm.core.type.model.SourceSystemElement;
import org.unidata.mdm.core.type.timeline.AbstractTimeInterval;
import org.unidata.mdm.core.type.timeline.TimeInterval;
import org.unidata.mdm.core.type.timeline.Timeline;
import org.unidata.mdm.data.context.AbstractRelationsFromRequestContext;
import org.unidata.mdm.data.context.GetRelationTimelineRequestContext;
import org.unidata.mdm.data.context.GetRelationsTimelineRequestContext;
import org.unidata.mdm.data.context.ReadOnlyTimelineContext;
import org.unidata.mdm.data.context.ReadWriteDataContext;
import org.unidata.mdm.data.context.ReadWriteTimelineContext;
import org.unidata.mdm.data.context.RelationFromIdentityContext;
import org.unidata.mdm.data.context.RelationIdentityContext;
import org.unidata.mdm.data.context.RelationToIdentityContext;
import org.unidata.mdm.data.context.TimelineQueryContext;
import org.unidata.mdm.data.context.UpsertRelationRequestContext;
import org.unidata.mdm.data.dao.RelationsDAO;
import org.unidata.mdm.data.dto.GetTimelineResult;
import org.unidata.mdm.data.dto.GetTimelinesResult;
import org.unidata.mdm.data.exception.DataExceptionIds;
import org.unidata.mdm.data.exception.DataProcessingException;
import org.unidata.mdm.data.po.data.RelationTimelinePO;
import org.unidata.mdm.data.po.keys.RecordOriginKeyPO;
import org.unidata.mdm.data.po.keys.RelationKeysPO;
import org.unidata.mdm.data.po.keys.RelationOriginKeyPO;
import org.unidata.mdm.data.type.apply.batch.impl.RelationDeleteBatchSet;
import org.unidata.mdm.data.type.apply.batch.impl.RelationUpsertBatchSet;
import org.unidata.mdm.data.type.calculables.impl.RelationRecordHolder;
import org.unidata.mdm.data.type.data.EtalonRelation;
import org.unidata.mdm.data.type.data.EtalonRelationInfoSection;
import org.unidata.mdm.data.type.data.OriginRelation;
import org.unidata.mdm.data.type.data.OriginRelationInfoSection;
import org.unidata.mdm.data.type.data.RelationType;
import org.unidata.mdm.data.type.data.impl.EtalonRelationImpl;
import org.unidata.mdm.data.type.data.impl.OriginRelationImpl;
import org.unidata.mdm.data.type.draft.DataDraftTags;
import org.unidata.mdm.data.type.keys.RecordKeys;
import org.unidata.mdm.data.type.keys.RecordOriginKey;
import org.unidata.mdm.data.type.keys.RelationKeys;
import org.unidata.mdm.data.type.keys.RelationOriginKey;
import org.unidata.mdm.data.type.timeline.RelationTimeInterval;
import org.unidata.mdm.data.type.timeline.RelationTimeline;
import org.unidata.mdm.draft.context.DraftGetContext;
import org.unidata.mdm.draft.context.DraftQueryContext;
import org.unidata.mdm.draft.dto.DraftGetResult;
import org.unidata.mdm.draft.dto.DraftQueryResult;
import org.unidata.mdm.draft.service.DraftService;
import org.unidata.mdm.draft.type.DraftTags;
import org.unidata.mdm.meta.configuration.Descriptors;
import org.unidata.mdm.meta.type.RelativeDirection;
import org.unidata.mdm.system.context.DraftAwareContext;
import org.unidata.mdm.system.type.runtime.MeasurementPoint;
import org.unidata.mdm.system.util.TimeBoundaryUtils;

/**
 * @author Mikhail Mikhailov
 * Contains functionality, common to all types of relations.
 */
@Component
public class CommonRelationsComponent {
    /**
     * The logger.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(CommonRelationsComponent.class);
    /**
     * Common functionality.
     */
    @Autowired
    private CommonRecordsComponent commonRecordsComponent;
    /**
     * Relations vistory DAO.
     */
    @Autowired
    private RelationsDAO relationsDao;
    /**
     * Meta model service.
     */
    @Autowired
    private MetaModelService metaModelService;
    /**
     * The composer.
     */
    @Autowired
    private RelationComposerComponent relationComposerComponent;
    /**
     * The DS.
     */
    @Autowired
    private DraftService draftService;
    /**
     * The RDPC.
     */
    @Autowired
    private RelationDraftProviderComponent relationDraftProviderComponent;
    /**
     * Constructor.
     */
    public CommonRelationsComponent() {
        super();
    }

    public RelationKeys ensureKeys(RelationIdentityContext ctx) {

        RelationKeys relationKeys = ensureAndGetRelationKeys(ctx);
        if (relationKeys == null) {
            final String message = "Relation keys can not be resolved!";
            LOGGER.warn(message);
            throw new DataProcessingException(message, DataExceptionIds.EX_DATA_RELATION_CONTEXT_NO_IDENTITY);
        }

        return relationKeys;
    }

    /**
     * Gets the from key from the context, if supplied.
     * @param ctx the context
     * @return keys or null
     */
    public RecordKeys ensureAndGetFromRecordKeys(AbstractRelationsFromRequestContext<?> ctx) {

        // Try to resolve from side
        RecordKeys from = ctx.keys();
        if (from == null) {
            from = commonRecordsComponent.identify(ctx);
        }

        return from;
    }

    /**
     * Gets relation keys resolving also the to side.
     * @param ctx context
     * @return keys or null
     */
    public RelationKeys ensureAndGetRelationKeys(RelationIdentityContext ctx) {

        RelationKeys keys = ctx.relationKeys();
        if (keys == null) {

            // 1. Try relation identity first
            if (ctx.isValidRelationKey()) {
                keys = identify(ctx);
            // 2. Try sides secondly
            } else {

                // 2.1 Depending on the direction of the view one side must already be resolved, if defined.
                // Check for presence and try to resolve identity on the other side, if not already done.
                RecordKeys from = ctx.getDirection() == RelativeDirection.FROM ? ((RelationFromIdentityContext) ctx).fromKeys() : ctx.keys();
                RecordKeys to = ctx.getDirection() == RelativeDirection.TO ? ((RelationToIdentityContext) ctx).toKeys() : ctx.keys();

                if (from == null && to == null) {
                    return null;
                }

                if (to == null && ctx.getDirection() == RelativeDirection.FROM) {

                    to = commonRecordsComponent.identify(ctx);
                    if (to == null) {
                        return null;
                    }

                    ctx.keys(to);
                }

                if (from == null && ctx.getDirection() == RelativeDirection.TO) {

                    from = commonRecordsComponent.identify(ctx);
                    if (from == null) {
                        return null;
                    }

                    ctx.keys(from);
                }

                // 2.2. Skip pointless keys resolution upon initial load.
                // May quite have an impact on millions of records
                boolean emptyStorage = ctx instanceof UpsertRelationRequestContext && ((UpsertRelationRequestContext) ctx).isEmptyStorage();
                if (!emptyStorage) {
                    keys = identify(ctx.relationName(), from, to);
                }
            }

            if (keys != null) {
                ctx.relationKeys(keys);
            }
        }

        return keys;
    }

    @SuppressWarnings("unchecked")
    public Timeline<OriginRelation> ensureAndGetRelationTimeline(RelationIdentityContext ctx) {

        Timeline<OriginRelation> tl = null;
        if (ctx instanceof ReadOnlyTimelineContext) {

            tl = ((ReadOnlyTimelineContext<OriginRelation>) ctx).currentTimeline();
            if (Objects.nonNull(tl)) {
                return tl;
            }
        }

        if (ctx.isValidRelationKey()) {

            tl = loadTimeline(GetRelationTimelineRequestContext.builder(ctx)
                    .fetchData(true)
                    .build());
        } else {

            RelationKeys keys = ensureAndGetRelationKeys(ctx);
            if (Objects.nonNull(keys)) {

                GetRelationTimelineRequestContext tlCtx = GetRelationTimelineRequestContext.builder(ctx)
                    .relationEtalonKey(keys.getEtalonKey().getId())
                    .relationLsn(keys.getEtalonKey().getLsn())
                    .relationShard(keys.getShard())
                    .fetchData(true)
                    .build();

                tlCtx.relationKeys(keys);
                tl = loadTimeline(tlCtx);
            } else if (ctx instanceof DraftAwareContext && ((DraftAwareContext) ctx).isDraftOperation()) {
                tl = loadTimeline(GetRelationTimelineRequestContext.builder(ctx)
                        .fetchData(true)
                        .build());
            }
        }

        if (Objects.isNull(tl)) {
            return new RelationTimeline(null);
        }

        if (ctx instanceof ReadOnlyTimelineContext) {
            ((ReadOnlyTimelineContext<OriginRelation>) ctx).currentTimeline(tl);
        }

        if (Objects.isNull(ctx.relationKeys())) {
            ctx.relationKeys(tl.getKeys());
        }

        return tl;
    }

    /**
     * Identify by relation keys.
     * @param ctx the context
     * @return keys
     */
    public RelationKeys identify(RelationIdentityContext ctx) {
        MeasurementPoint.start();
        try {

            RelationKeys keys = null;
            if (ctx.isRelationLsnKey()) {
                RelationKeysPO po = relationsDao.loadKeysByLSN(ctx.getShard(), ctx.getLsn());
                keys = relationComposerComponent.toRelationKeys(po, lsnKeyPredicate());
            }

            if (keys == null && ctx.isRelationEtalonKey()) {
                RelationKeysPO po = relationsDao.loadKeysByEtalonId(UUID.fromString(ctx.getRelationEtalonKey()));
                keys = relationComposerComponent.toRelationKeys(po, etalonKeyPredicate());
            }

            if (Objects.nonNull(keys)) {
                ctx.relationKeys(keys);
            }

            return keys;
        } finally {
            MeasurementPoint.stop();
        }
    }
    /**
     * Resolves keys by sides.
     * @param name relation name
     * @param from the from side
     * @param to the to side
     * @return keys or null
     */
    public RelationKeys identify(String name, AbstractRelationsFromRequestContext<?> from, RelationFromIdentityContext to) {
        MeasurementPoint.start();
        try {

            if (from == null || to == null) {
                return null;
            }

            RelationKeys keys = null;
            if (to.isValidRelationKey()) {
                keys = identify(to);
            }

            if (keys == null && from.isEtalonRecordKey() && to.isEtalonRecordKey()) {
                RelationKeysPO po = relationsDao.loadKeysByRecordsEtalonIds(UUID.fromString(from.getEtalonKey()), UUID.fromString(to.getEtalonKey()), name);
                keys = relationComposerComponent.toRelationKeys(po, etalonKeysPredicate());
            } else if (keys == null && (from.isLsnKey() && to.isLsnKey())) {
                RelationKeysPO po = relationsDao.loadKeysByRecordsLSNs(from.getShard(), from.getLsn(), to.getShard(), to.getLsn(), name);
                keys = relationComposerComponent.toRelationKeys(po, lsnKeysPredicate());
            } else if (keys == null && (from.isOriginExternalId() && to.isOriginExternalId())) {
                RelationKeysPO po = relationsDao.loadKeysByRecordsExternalIds(from.getExternalIdAsObject(), to.getExternalIdAsObject(), name);
                keys = relationComposerComponent.toRelationKeys(po, externalIdsPredicate(
                        from.getExternalId(), from.getSourceSystem(),
                        to.getExternalId(), to.getSourceSystem()));
            }

            if (Objects.nonNull(keys)) {
                to.relationKeys(keys);
            }

            return keys;
        } finally {
            MeasurementPoint.stop();
        }
    }
    /**
     * Identify relation by sides keys
     * @param name relation name
     * @param from the from side
     * @param to the to side
     * @return relation keys
     */
    public RelationKeys identify(String name, RecordKeys from, RecordKeys to) {
        MeasurementPoint.start();
        try {

            if (from == null || to == null) {
                return null;
            }

            RelationKeys keys = null;
            if ((from.getEtalonKey() != null && from.getEtalonKey().getId() != null)
             && (to.getEtalonKey() != null && to.getEtalonKey().getId() != null)) {
                RelationKeysPO po = relationsDao.loadKeysByRecordsEtalonIds(UUID.fromString(from.getEtalonKey().getId()), UUID.fromString(to.getEtalonKey().getId()), name);
                keys = relationComposerComponent.toRelationKeys(po, etalonKeysPredicate());
            } else if (
                (from.getEtalonKey() != null && from.getEtalonKey().getLsn() != null)
             && (to.getEtalonKey() != null && to.getEtalonKey().getLsn() != null)) {
                RelationKeysPO po = relationsDao.loadKeysByRecordsLSNs(
                        from.getShard(), from.getEtalonKey().getLsn(),
                        to.getShard(), to.getEtalonKey().getLsn(),
                        name);
                keys = relationComposerComponent.toRelationKeys(po, lsnKeysPredicate());
            } else if (
                (from.getOriginKey() != null && StringUtils.isNoneBlank(
                     from.getOriginKey().getExternalId(),
                     from.getOriginKey().getEntityName(),
                     from.getOriginKey().getSourceSystem()))
             && (to.getOriginKey() != null) && StringUtils.isNoneBlank(
                     to.getOriginKey().getExternalId(),
                     to.getOriginKey().getEntityName(),
                     to.getOriginKey().getSourceSystem())) {
                RelationKeysPO po = relationsDao.loadKeysByRecordsExternalIds(
                        from.getOriginKey().toExternalId(), to.getOriginKey().toExternalId(), name);
                keys = relationComposerComponent.toRelationKeys(po, externalIdsPredicate(
                        from.getOriginKey().getExternalId(), from.getOriginKey().getSourceSystem(),
                        to.getOriginKey().getExternalId(), to.getOriginKey().getSourceSystem()));
            }

            return keys;
        } finally {
            MeasurementPoint.stop();
        }
    }

    public BiPredicate<RelationKeysPO, RelationOriginKeyPO> etalonKeyPredicate() {
        return (po, okpo) -> StringUtils.equals(okpo.getSourceSystem(), metaModelService.instance(Descriptors.SOURCE_SYSTEMS).getAdminElement().getName())
               && okpo.getInitialOwner().equals(UUID.fromString(po.getId())) && !okpo.isEnrichment();
    }

    public BiPredicate<RelationKeysPO, RelationOriginKeyPO> lsnKeyPredicate() {
        return etalonKeyPredicate();
    }

    public BiPredicate<RelationKeysPO, RelationOriginKeyPO> etalonKeysPredicate() {
        return etalonKeyPredicate();
    }

    public BiPredicate<RelationKeysPO, RelationOriginKeyPO> lsnKeysPredicate() {
        return etalonKeyPredicate();
    }

    public BiPredicate<RelationKeysPO, RelationOriginKeyPO> externalIdsPredicate(
            String fromExternalId, String fromSourceSystem,
            String toExternalId, String toSourceSystem) {
        return (po, okpo) -> {

            RecordOriginKeyPO from = Objects.isNull(po.getFromKeys())
                    ? null
                    : po.getFromKeys().findByExternalId(fromExternalId, fromSourceSystem);

            RecordOriginKeyPO to = Objects.isNull(po.getToKeys())
                    ? null
                    : po.getToKeys().findByExternalId(toExternalId, toSourceSystem);

            if (Objects.nonNull(from) && Objects.nonNull(to)) {
                return from.getId().equals(okpo.getFromKey()) && to.getId().equals(okpo.getToKey());
        }

            return false;
        };
    }
    /**
     * Loads timeline respective to given side.
     * @param ctx the context
     * @return timelines map
     */
    public Map<String, List<Timeline<OriginRelation>>> loadTimelines(GetRelationsTimelineRequestContext ctx) {

        MeasurementPoint.start();
        try {

            GetTimelinesResult<OriginRelation> result = loadTimelinesExt(ctx);
            return result.getTimelines().entrySet().stream()
                    .collect(Collectors.toMap(Entry::getKey, entry -> entry.getValue().stream()
                            .map(GetTimelineResult::getTimeline)
                            .collect(Collectors.toList())));

        } finally {
            MeasurementPoint.stop();
        }
    }
    /**
     * Loads timeline respective to given side.
     * @param ctx the context
     * @return timelines map
     */
    public GetTimelinesResult<OriginRelation> loadTimelinesExt(GetRelationsTimelineRequestContext ctx) {

        MeasurementPoint.start();
        try {

            // 1. Ensure keys
            String recordEtalonId = null;
            if (ctx.isEtalonRecordKey()) {
                recordEtalonId = ctx.getEtalonKey();
            } else {
                RecordKeys recordKeys = commonRecordsComponent.ensureKeys(ctx);
                recordEtalonId = recordKeys.getEtalonKey().getId();
            }

            // 2. Load data
            // 2.1. Drafts
            Map<String, GetTimelineResult<OriginRelation>> drafts = buildDraftTimelines(ctx);

            // 2.2. Approved
            List<RelationTimelinePO> tls
                = relationsDao.loadTimelines(
                        UUID.fromString(recordEtalonId),
                        ctx.getRelationNames(),
                        ctx.isFetchByToSide(),
                        ctx.isFetchData(),
                        ctx.getForLastUpdate(),
                        ctx.getForUpdatesAfter(),
                        ctx.getForOperationId());

            // 3. Extract data
            GetTimelinesResult<OriginRelation> result = new GetTimelinesResult<>();
            for (RelationTimelinePO po : tls) {

                GetTimelineResult<OriginRelation> selection = drafts.remove(po.getKeys().getId());
                if (Objects.isNull(selection)) {
                    selection = buildRegularTimeline(ctx, relationComposerComponent.toRelationKeys(po.getKeys(), etalonKeyPredicate()), po);
                }

                if (selection.getTimeline().<RelationKeys>getKeys().getEtalonKey().isActive() || ctx.isIncludeInactive()) {
                    result.add(selection.getTimeline().<RelationKeys>getKeys().getRelationName(), selection);
                }
            }

            // 3.1. New relations never approved
            for (Entry<String, GetTimelineResult<OriginRelation>> re : drafts.entrySet()) {
                result.add(re.getValue().getTimeline().<RelationKeys>getKeys().getRelationName(), re.getValue());
            }

            // 4. Possibly reduce timeline for Reference type
            if (!ctx.isReduceReferences()) {
                return result;
            }

            for (Entry<String, List<GetTimelineResult<OriginRelation>>> entry : result.getTimelines().entrySet()) {

                if (entry.getValue().isEmpty() || entry.getValue().get(0).getTimeline().<RelationKeys>getKeys().getRelationType() != RelationType.REFERENCES) {
                    continue;
                }

                List<Timeline<OriginRelation>> collected = entry.getValue().stream().map(GetTimelineResult::getTimeline).collect(Collectors.toList());
                List<Timeline<OriginRelation>> calculated = buildVirtualTimelinesForReferences(collected);

                entry.getValue().clear();
                entry.getValue().addAll(calculated.stream()
                        .map(timeline -> {
                            if (Objects.nonNull(ctx.getForDatesFrame())) {
                                return timeline.reduceBy(ctx.getForDatesFrame().getLeft(), ctx.getForDatesFrame().getRight());
                            } else if (Objects.nonNull(ctx.getForDate())) {
                                return timeline.reduceAsOf(ctx.getForDate());
                            } else {
                                return timeline;
                            }
                        })
                        .map(GetTimelineResult::new)
                        .collect(Collectors.toList()));
            }

            return result;

        } finally {
            MeasurementPoint.stop();
        }
    }
    /**
     * Loads (calculates) contributing relations ('to' participants) time line
     * for an etalon ID.
     *
     * @param ctx the context
     * @return timeline
     */
    public Timeline<OriginRelation> loadTimeline(GetRelationTimelineRequestContext ctx) {
        GetTimelineResult<OriginRelation> result = loadTimelineExt(ctx);
        return result.getTimeline();
    }

    /**
     * Loads (calculates) contributing relations ('to' participants) time line
     * for an etalon ID.
     *
     * @param ctx the context
     * @return timeline
     */
    public GetTimelineResult<OriginRelation> loadTimelineExt(GetRelationTimelineRequestContext ctx) {

        MeasurementPoint.start();
        try {

            // 0. Check draft indicator present and handle if needed
            if (ctx.isDraftOperation()) {

                GetTimelineResult<OriginRelation> result = buildDraftTimeline(ctx);
                if (result.getDraftId() > 0) {
                    return result;
                }
            }

            RelationTimelinePO po = null;

            // 1. Ensure keys
            RelationKeys keys = ctx.relationKeys();

            BiPredicate<RelationKeysPO, RelationOriginKeyPO> matcher = null;

            // 3. Load TL. Fetch with keys always, otherwise CONTAINMENTs cannot be build
            if (Objects.nonNull(keys)) {
                po = relationsDao.loadTimeline(UUID.fromString(keys.getEtalonKey().getId()), true, ctx.isFetchData(), ctx.getForLastUpdate(), ctx.getForUpdatesAfter(), ctx.getForOperationId());
            } else if (ctx.isRelationLsnKey()) {
                po = relationsDao.loadTimeline(ctx.getRelationLsnAsObject(), true, ctx.isFetchData(), ctx.getForLastUpdate(), ctx.getForUpdatesAfter(), ctx.getForOperationId());
                matcher = lsnKeyPredicate();
            } else if (ctx.isRelationEtalonKey()) {
                po = relationsDao.loadTimeline(UUID.fromString(ctx.getRelationEtalonKey()), true, ctx.isFetchData(), ctx.getForLastUpdate(), ctx.getForUpdatesAfter(), ctx.getForOperationId());
                matcher = etalonKeyPredicate();
            }

            if (Objects.nonNull(po) && Objects.nonNull(matcher)) {
                keys = relationComposerComponent.toRelationKeys(po.getKeys(), matcher);
            }

            // 4. Translate to timeline
            return buildRegularTimeline(ctx, keys, po);

        } finally {
            MeasurementPoint.stop();
        }
    }

    /**
     * Checks whether time lines of a relation for a 'from' etalon id have an active period.
     * @param etalonId 'from' record etalon id
     * @return true, if there are active periods, false otherwise
     */
    public boolean hasActivePeriodsFromPerspective(RelationKeys key) {

        GetRelationTimelineRequestContext ctx = GetRelationTimelineRequestContext.builder()
                .build();

        ctx.relationKeys(key);

        Timeline<OriginRelation> timeline = loadTimeline(ctx);
        for (TimeInterval<OriginRelation> period : timeline) {
            if (relationComposerComponent.isActive(period.toList())) {
                return true;
            }
        }

        return false;
    }


    @SuppressWarnings("rawtypes")
    public List<Timeline<OriginRelation>> loadOrReuseCachedTimelines(@Nonnull RelationIdentityContext ctx) {

        if (ctx.relationType() != RelationType.REFERENCES) {
            return Collections.emptyList();
        }

        RelationKeys keys = ctx.relationKeys();

        // ReferenceRelationContext
        List<Timeline<OriginRelation>> result = null;
        if (ctx instanceof ReadWriteTimelineContext) {

            ChangeSet set = ((ReadWriteDataContext) ctx).changeSet();
            if (set instanceof RelationUpsertBatchSet) {
                result = ((RelationUpsertBatchSet) set)
                        .findCachedReferenceTimelines(keys.getEtalonKey().getFrom().getId(), keys.getRelationName());
            } else if (set instanceof RelationDeleteBatchSet) {
                result = ((RelationDeleteBatchSet) set)
                        .findCachedReferenceTimelines(keys.getEtalonKey().getFrom().getId(), keys.getRelationName());
            }
        }

        if (Objects.nonNull(result)) {
            return result;
        }

        // Check references for overlapping. Only one reference of a type is allowed for a period
        GetRelationsTimelineRequestContext siblings = GetRelationsTimelineRequestContext.builder()
                .fetchData(true)
                .etalonKey(keys.getEtalonKey().getFrom().getId())
                .relationNames(keys.getRelationName())
                .build();

        result = loadTimelines(siblings).get(keys.getRelationName());
        return result == null ? Collections.emptyList() : result;
    }

    public List<Timeline<OriginRelation>> buildVirtualTimelinesForReferences(List<Timeline<OriginRelation>> real) {

        // Collect
        Map<OriginRelation, Pair<EtalonRelation, RelationKeys>> links = new IdentityHashMap<>();
        List<CalculableHolder<OriginRelation>> revisions = buildVirtualRevisions(real, links);

        // Build
        Timeline<OriginRelation> virtual = new RelationTimeline(null, revisions);

        // Different to records - do compact timeline by extending neighboring periods
        Map<RelationKeys, List<TimeInterval<OriginRelation>>> compacted = new IdentityHashMap<>();
        OriginRelation last = null;
        for (TimeInterval<OriginRelation> ti : virtual) {

            List<CalculableHolder<OriginRelation>> calculables = ti.toList();
            OriginRelation current = relationComposerComponent.toBVR(calculables, true, false);
            if (Objects.isNull(current)) {
                last = null;
                continue;
            }

            Pair<EtalonRelation, RelationKeys> linked = links.get(current);
            EtalonRelation data = linked.getKey();
            RelationKeys keys = linked.getValue();

            List<TimeInterval<OriginRelation>> intervals = compacted.computeIfAbsent(linked.getValue(), k -> new ArrayList<>());
            AbstractTimeInterval<OriginRelation> i = intervals.isEmpty() ? null : (AbstractTimeInterval<OriginRelation>) intervals.get(intervals.size() - 1);

            // Leader change
            if (i == null || last != current) {
                i = new RelationTimeInterval(ti.getValidFrom(), ti.getValidTo(), calculables);
                i.setActive(current.getInfoSection().getStatus() == RecordStatus.ACTIVE);
                i.setCalculationResult(new EtalonRelationImpl()
                        .withDataRecord(data)
                        .withInfoSection(new EtalonRelationInfoSection()
                                .withRelationEtalonKey(keys.getEtalonKey().getId())
                                .withRelationName(keys.getRelationName())
                                .withRelationType(keys.getRelationType())
                                .withPeriodId(TimeBoundaryUtils.toUpperBound(ti.getValidTo()))
                                .withValidFrom(ti.getValidFrom())
                                .withValidTo(ti.getValidTo())
                                .withStatus(current.getInfoSection().getStatus())
                                .withOperationType(current.getInfoSection().getOperationType())
                                .withCreateDate(current.getInfoSection().getCreateDate())
                                .withUpdateDate(current.getInfoSection().getUpdateDate())
                                .withCreatedBy(current.getInfoSection().getCreatedBy())
                                .withUpdatedBy(current.getInfoSection().getUpdatedBy())
                                .withFromEntityName(keys.getFromEntityName())
                                .withFromEtalonKey(keys.getEtalonKey().getFrom())
                                .withToEntityName(keys.getToEntityName())
                                .withToEtalonKey(keys.getEtalonKey().getTo())));

                intervals.add(i);
                last = current;
                continue;
            }

            // Continuation. Compact timeline by extending period.
            EtalonRelation collected = i.getCalculationResult();
            i.setValidTo(ti.getValidTo());
            collected.getInfoSection()
                .withValidTo(ti.getValidTo())
                .withPeriodId(TimeBoundaryUtils.toUpperBound(ti.getValidTo()));
        }

        return compacted.entrySet().stream()
                .map(entry -> new RelationTimeline(entry.getKey(), entry.getValue()))
                .collect(Collectors.toList());
    }

    private Map<String, GetTimelineResult<OriginRelation>> buildDraftTimelines(GetRelationsTimelineRequestContext ctx) {

        if (!ctx.isDraftOperation()) {
            return Collections.emptyMap();
        }

        List<String> tags = null;
        if (CollectionUtils.isNotEmpty(ctx.getRelationNames())) {
            tags = new ArrayList<>(ctx.getRelationNames().size());
            for (String relationName : ctx.getRelationNames()) {
                tags.add(DraftTags.toTag(DataDraftTags.ENTITY_NAME, relationName));
            }
        }

        DraftQueryResult result = draftService.drafts(DraftQueryContext.builder()
                .parentDraftId(ctx.getParentDraftId())
                .provider(RelationDraftProviderComponent.ID)
                .tags(tags)
                .build());

        return result.getDrafts().stream()
            .map(d -> loadTimelineExt(GetRelationTimelineRequestContext.builder()
                    .draftId(d.getDraftId())
                    .relationEtalonKey(d.getSubjectId())
                    .fetchKeys(true)
                    .fetchData(true)
                    .build()))
            .collect(Collectors.toMap(tlr -> tlr.getTimeline().<RelationKeys>getKeys().getEtalonKey().getId(), Function.identity()));
    }

    private GetTimelineResult<OriginRelation> buildDraftTimeline(GetRelationTimelineRequestContext ctx) {

        Long draftId = relationDraftProviderComponent.selectDraftId(ctx);
        if (Objects.nonNull(draftId)) {

            DraftGetContext dgc = DraftGetContext.builder()
                    .draftId(draftId)
                    .payload(ctx)
                    .build();

            DraftGetResult result = draftService.get(dgc);
            if (result.hasPayload()) {
                return result.narrow();
            }
        }

        return new GetTimelineResult<>(new RelationTimeline(null));
    }

    private GetTimelineResult<OriginRelation> buildRegularTimeline(TimelineQueryContext ctx, RelationKeys keys, RelationTimelinePO po) {

        // 4. Translate to internal
        Timeline<OriginRelation> timeline = relationComposerComponent.toRelationTimeline(keys, po == null ? null : po.getVistory());

        boolean reduceReferences =
                ((ctx instanceof GetRelationsTimelineRequestContext) && ((GetRelationsTimelineRequestContext) ctx).isReduceReferences())
                && keys.getRelationType() == RelationType.REFERENCES;

        // 4.1. Possibly reduce TL by given boundaries.
        // Maybe a separate, more efficient request will be written later on.
        if (!reduceReferences) {
            if (Objects.nonNull(ctx.getForDatesFrame())) {
                timeline = timeline.reduceBy(ctx.getForDatesFrame().getLeft(), ctx.getForDatesFrame().getRight());
            } else if (Objects.nonNull(ctx.getForDate())) {
                timeline = timeline.reduceAsOf(ctx.getForDate());
            }
        }

        // 4.2 Calc suff, if not disabled
        RelationKeys rk = timeline.getKeys();
        if (!ctx.isSkipCalculations()) {
            timeline.forEach(ti -> {

                List<CalculableHolder<OriginRelation>> calculables = ti.toList();

                ti.setActive(relationComposerComponent.isActive(calculables));

                if (ctx.isFetchData()) {
                    ti.setCalculationResult(relationComposerComponent.toEtalon(rk, calculables,
                            ti.getValidFrom(), ti.getValidTo(), true, false));
                }
            });
        }

        return new GetTimelineResult<>(timeline);
    }

    private List<CalculableHolder<OriginRelation>> buildVirtualRevisions(List<Timeline<OriginRelation>> real, Map<OriginRelation, Pair<EtalonRelation, RelationKeys>> links) {

        SourceSystemElement sse = metaModelService.instance(Descriptors.SOURCE_SYSTEMS).getAdminElement();
        List<CalculableHolder<OriginRelation>> revisions = new ArrayList<>();

        for (Timeline<OriginRelation> tl : real) {

            for (TimeInterval<OriginRelation> ti : tl) {

                Optional<Date> lud = ti.toValueList().stream()
                        .map(or -> or.getInfoSection().getUpdateDate() != null
                            ? or.getInfoSection().getUpdateDate()
                            : or.getInfoSection().getCreateDate())
                        .max(Comparator.naturalOrder());

                RelationKeys relationKeys = tl.getKeys();
                EtalonRelation etalon = ti.getCalculationResult();
                OriginRelation vo = new OriginRelationImpl()
                    .withDataRecord(etalon)
                    .withInfoSection(new OriginRelationInfoSection()
                        .withRelationName(relationKeys.getRelationName())
                        .withValidFrom(ti.getValidFrom())
                        .withValidTo(ti.getValidTo())
                        .withCreateDate(relationKeys.getCreateDate())
                        .withCreatedBy(relationKeys.getCreatedBy())
                        .withUpdateDate(lud.orElse(null))
                        .withUpdatedBy(relationKeys.getUpdatedBy())
                        .withFromEntityName(relationKeys.getFromEntityName())
                        .withToEntityName(relationKeys.getToEntityName())
                        .withRelationType(relationKeys.getRelationType())
                        .withStatus(ti.isActive() ? RecordStatus.ACTIVE : RecordStatus.INACTIVE)
                        .withShift(DataShift.REVISED)
                        .withOperationType(Objects.nonNull(etalon) ? etalon.getInfoSection().getOperationType() : OperationType.DIRECT)
                        .withRelationOriginKey(RelationOriginKey.builder()
                                .from(RecordOriginKey.builder()
                                        .createDate(relationKeys.getCreateDate())
                                        .createdBy(relationKeys.getCreatedBy())
                                        .updateDate(lud.orElse(null))
                                        .updatedBy(relationKeys.getUpdatedBy())
                                        .enrichment(false)
                                        .id(relationKeys.getEtalonKey().getFrom().getId())
                                        .sourceSystem(sse.getName())
                                        .externalId(relationKeys.getEtalonKey().getFrom().getId())
                                        .entityName(relationKeys.getFromEntityName())
                                        .status(ti.isActive() ? RecordStatus.ACTIVE : RecordStatus.INACTIVE)
                                        .build())
                                .to(RecordOriginKey.builder()
                                        .createDate(relationKeys.getCreateDate())
                                        .createdBy(relationKeys.getCreatedBy())
                                        .updateDate(lud.orElse(null))
                                        .updatedBy(relationKeys.getUpdatedBy())
                                        .enrichment(false)
                                        .id(relationKeys.getEtalonKey().getTo().getId())
                                        .sourceSystem(sse.getName())
                                        .externalId(relationKeys.getEtalonKey().getTo().getId())
                                        .entityName(relationKeys.getToEntityName())
                                        .status(ti.isActive() ? RecordStatus.ACTIVE : RecordStatus.INACTIVE)
                                        .build())
                                .createDate(relationKeys.getCreateDate())
                                .createdBy(relationKeys.getCreatedBy())
                                .updateDate(lud.orElse(null))
                                .updatedBy(relationKeys.getUpdatedBy())
                                .enrichment(false)
                                .id(relationKeys.getEtalonKey().getId())
                                .sourceSystem(sse.getName())
                                .status(ti.isActive() ? RecordStatus.ACTIVE : RecordStatus.INACTIVE)
                                .build()));

                links.put(vo, Pair.of(etalon, tl.getKeys()));
                revisions.add(new RelationRecordHolder(vo));
            }
        }

        return revisions;
    }
}
